iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0
Software Development

30 天的 Functional Programming 之旅系列 第 23

[Day 23] Applicative Functor (1):應用被包裹的函數

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20251007/20168201p4mr6xSAns.png

前言

在前面的文章中,我們認識了 Functor 和 Monad。Functor 透過 map 方法,讓我們能將一個普通的函數應用到被包裹在 context(或我們常說的「容器」)裡的值。接著,Monad 透過 chain (或 flatMapbind) 方法,讓我們能夠串接一系列會回傳容器的函數,解決了 Functor 巢狀容器的問題。

Functor 讓我們用一個「一般值的世界」的普通函數,去操作「容器包裹值的世界」的值。Monad 讓我們串連起多個會產生「容器包裹值」的計算。這兩個工具都幫助我們更好的處理函數組合、讓我們的資料處理管線不會隨意斷裂。

但現在有一個新的問題:如果我們想應用的那個函數,它本身也被包裹在一個容器裡呢?
舉個簡單的例子:

const add = a => b => a + b;

Maybe.of(2).map(add);
// Maybe(b => 2 + b)

結果是一個包在 Maybe 裡的部分應用函數。那要怎麼把 Maybe(b => 2 + b) 應用在 Maybe.of(3) 上然後得到 Maybe(5) 呢?

map 只能接受一個普通的、沒有被包進容器的函數。chain 則是接受一個會回傳新容器的函數。兩者都無法處理一個「已經在容器裡的函數」。

https://ithelp.ithome.com.tw/upload/images/20251007/20168201kHj1S6xT77.png
圖 1 mapchain 的限制(資料來源: 自行繪製)

這個看似奇怪的問題,其實在實務中常會遇到,尤其當我們處理需要多個參數的函數,而每個參數都來自一個獨立的、可能失敗或可能非同步的計算時。而這就是 Applicative Functor 要解決的問題,也是今天的主題~

為什麼要有 Applicative?

假設我們需要根據主題(theme)和連接埠(port)來建立一個網站設定物件。有一個柯里化 (curried) 的函數來做這件事:

// createConfig :: String -> Number -> { theme: String, port: Number }
const createConfig = curry((theme, port) => ({ theme, port }));

這兩個設定值是從外部讀取的,它們可能存在也可能不存在,所以我們用 Maybe 這容器來處理。

const theme = Maybe.of('dark');      // Just('dark')
const port = Maybe.of(8080);       // Just(8080)

如果 themeport 都存在(都是 Just),就用它們的值來呼叫 createConfig;只要有任何一個是 Nothing,最終結果就應該是 Nothing
要讓函數 createConfig 可以應用到容器內的值,很自然會想到用 map

// theme.map(createConfig) :: Maybe (Number -> { theme: String, port: Number })

const maybeConfigFn = theme.map(createConfig);
// 結果: Just(port => ({ theme: 'dark', port }))

現在 createConfig 應用到了 theme 的值上。因為 createConfig 是柯里化函數,所以它回傳了一個還需要 port 參數的新函數,而這個新函數被包裹在 Maybe 容器裡。

現在我們有一個 Maybe(Function) 和一個 Maybe(Number),該如何將前者應用於後者呢?

我們不能使用 map,因為會報錯:

port.map(maybeConfigFn); // 🔺 Uncaught TypeError: fn is not a function

(這段完整程式請見連結
為什麼不行?因為 map 的定義是接受一個普通的、未被包裹的函數作為參數。但我們現在傳給它的 maybeConfigFn 是一個 Maybe 物件,一個被包裹起來的函數。

https://ithelp.ithome.com.tw/upload/images/20251007/20168201uG7rZGY9gT.png
圖 2 map 只能接受未被包裹的一般函數(資料來源: 自行繪製)

由此可知 Functor 的 map 有其限制,map 適合處理單一參數或一連串的單一轉換,但當一個函數需要的多個參數各自被包裹在獨立的容器時,map 就無法處理了。這也是 FP 世界中處理多個「帶有副作用」(effectful)的輸入時,必然會遇到的挑戰。

Monad 的解法與其限制

Functor 的 map 無法處理,但我們可用 chainmap 的組合來達成目標:

theme.chain(t =>
  port.map(p => createConfig(t)(p))
);
// 結果: Just({ theme: 'dark', port: 8080 })

我們用 chain 來「打開」第一個 Maybe 容器 (theme),取出裡面的值 t。然後在這個 chain 的回呼函數內部,我們現在處於一個可以存取一般值 t 的環境中。接著,我們對第二個 Maybe 容器 (port) 使用 map,將已經部分應用的函數 createConfig(t) 傳給它,最終得到結果。

但這裡隱藏著一個細節:序列性 (sequentiality)chain 的本質決定了計算的順序。port.map(...) 這段程式碼,必須等到 theme.chain(...) 的回呼函數被執行時才能開始。換句話說,第二個計算(處理 port)被強制安排在第一個計算(處理 theme)完成之後 。

在目前的例子中,延遲可能沒什麼關係,但假設 themeport 來自兩個獨立的網路請求(例如,兩個 TaskPromise)。Monad 的作法就會將這兩個可以並行處理的請求,變成一個循序的流程,讓兩個不是相依的事件綁在同一個時間線的流程上,造成不必要的等待。

這個「不必要的序列性」正是 Applicative 想要解決的痛點之一。它提供了一種組合獨立計算的方式,而不會強加循序的依賴關係。

ap 打通兩個世界

為了解決這問題,Applicative Functor 因此出現。Applicative 是 Functor 的一種延伸,它在 Functor 的基礎上,增加了一個關鍵方法:ap(apply 的縮寫)。

ap 的目的非常明確:將一個包裹在容器裡的函數,應用到一個包裹在同類型容器裡的值。

預期可以這樣使用:

Maybe.of(add(2)).ap(Maybe.of(3));
// => Just(5)
// 將 add(2) 這個函數值套用到 3 這個資料值上

另一個 map 加上 ap 的等價寫法是:

Maybe.of(2).map(add).ap(Maybe.of(3));
// => Just(5)

其中 add 必須是 curry 函數,否則 map(add) 不會產生部分應用的函數來繼續接收下一個參數。

接著來看 ap 方法實作:

Container.prototype.ap = function (otherContainer) {
  return otherContainer.map(this.$value); 
};
  • this.$value 是一個函數(例如:add(2)maybeConfigFn
  • otherContainer 是包含要套用的值的 Functor(例如:Container(3)Maybe.of(8080)

我們可以直接拿之前實作的 Maybe 來加上 ap 方法。

class Just {
  constructor(value) { this.$value = value; }
  map(fn) { return Maybe.of(fn(this.$value)); }
  chain(fn) { return fn(this.$value); }

  // 新增 ap 方法
  ap(otherMaybe) {
    // this.$value 是一個函式
    // 我們將這個函式 map 到另一個 Maybe 上
    return otherMaybe.map(this.$value);
  }

  toString() { return `Just(${this.$value})`; }
}

class Nothing {
  map(fn) { return this; }
  chain(fn) { return this; }

  // 新增 ap 方法
  ap(otherMaybe) {
    // 如果包裹函式的容器是 Nothing,結果永遠是 Nothing
    return this;
  }

  toString() { return 'Nothing()'; }
}

// Maybe.of 維持不變
const Maybe = {
  of: (value) => value === null || value === undefined? new Nothing() : new Just(value)
};

有了 ap ,就可以回頭解決最初的問題:

const theme = Maybe.of('dark');
const port = Maybe.of(8080);

const maybeConfigFn = theme.map(createConfig);
// maybeConfigFn is Just(port => ({ theme: 'dark', port }))

// 使用 ap 來應用包裹起來的函式!
const finalConfig = maybeConfigFn.ap(port);
// 結果: Just({theme: "dark", port: 8080})

完整程式可見此連結

ap 方法就像一把鑰匙,打開了兩個容器之間的門。maybeConfigFn(一個 Maybe)呼叫了 ap 方法,並將 port(另一個 Maybe)作為參數傳入。

ap 的內部邏輯會檢查兩個 Maybe:如果兩者都是 Just,它就會取出裡面的函數和值,將它們應用起來,然後將結果用 Just 包裹後回傳。如果其中任何一個是 Nothing,結果就是 Nothing

https://ithelp.ithome.com.tw/upload/images/20251007/20168201PKZKg9KqPk.png
圖 3 ap 拿出容器裡的函數,再應用到容器裡的值,得到的新值包回容器(資料來源: 自行繪製)

所以 Applicative 是什麼?

一個 Applicative Functor,或簡稱 Applicative,是一個資料結構,它首先是一個 Functor,並且額外實作了兩個核心方法:

1. ap:用於將一個包裹起來的函數應用到一個包裹起來的值

ap 方法的型別簽章是:

ap :: Apply f => f (a -> b) -> f a -> f b

可以用之前提過的「兩個世界」的比喻來理解 ap

  • 一般值的世界:有值例如:'dark'8080,和函數如 createConfig
  • 容器包裹值的世界:有被包裹的值如 Maybe.of('dark')Maybe.of(8080)

map 就像一個傳送門,它允許我們把一個「一般值的世界」的函數傳送到「Maybe 世界」裡去作用於一個值。
ap 則是在「Maybe 世界」內部建立的一座橋樑,它讓一個已經在「Maybe 世界」的函數,可以跨越到另一個值上並對其產生作用。

https://ithelp.ithome.com.tw/upload/images/20251007/20168201nmmcvxpO57.png
圖 4 ap 是容器世界內的橋樑(資料來源: 自行繪製)

ap 的出現改變了我們的組合方式,組合時我們不再只是用 map(f).map(g) 這樣的線性鏈,而是演變成一種可以將多個獨立計算分支合併為單一結果的圖狀結構。這種結構差異,正是 Applicative 特別適合處理並行操作與多重驗證的原因。

2. of:一個靜態方法,它接受一個一般世界的值,並將其放入一個預設的、最小的 context 中

of 方法的型別簽章是:

of::Applicative f => a −> f a

補充一下,我們之前介紹的 Maybe、Either、IO 與 Task 其實從一開始就有定義 of 方法,這是為了使用方便而定義 of 作為建構器,然而關鍵差異是,對於一個結構要能被稱作 Applicative,of 方法是其正式且必要的介面之一 。Functor 本身只要求 map,但 Applicative 則必須提供 of,以保證我們總能將一個一般值「提升」(lift) 到這個容器中。

of 的作用是我們進入 Applicative 世界的起點。如果 mapap 是在容器包裹值的世界內的操作,那 of 就是將一個一般值從「一般值的世界」提升到「Applicative 世界」的官方入口。

https://ithelp.ithome.com.tw/upload/images/20251007/20168201MDbA14uKag.png
圖 5 of 將一般值從「一般值的世界」提升到「Applicative 世界」(資料來源: 自行繪製)

我們可以利用 of,將先前的例子寫成一個更連貫的鏈式呼叫:

// 舊寫法
// const maybeConfigFn = theme.map(createConfig);
// const finalConfig = maybeConfigFn.ap(port);

// Applicative 風格的寫法
const finalConfig = Maybe.of(createConfig)
.ap(theme)
.ap(port);

// 結果: Just({theme: "dark", port: 8080})

這個寫法更優雅地表達了我們的目的:將 createConfig 這個一般函數提升到 Maybe 世界,然後依序將 themeport 這兩個包裹起來的值應用給它。

mapap 的關係

我一開始以為 mapap 是兩種截然不同的操作方法,但其實 map 可以被視為 ap 的一個特例。ap 的特性讓它能帶出這個等式:

F.of(x).map(f) === F.of(f).ap(F.of(x));
  • 左側 F.of(x).map(f):這是我們熟悉的 Functor 模式。將一個普通、未被包裹的函數 f,應用到一個包裹起來的值 F.of(x)
  • 右側 F.of(f).ap(F.of(x)):這是 Applicative 模式。先用 of 將普通函數 f 提升到容器中,變成一個包裹起來的函數 F.of(f),然後再用 ap 將它應用到包裹起來的值上。

https://ithelp.ithome.com.tw/upload/images/20251007/201682014lpK6whJLc.png
圖 6 mapap 的關係示意圖(資料來源: 自行繪製)

這個等式告訴我們,任何 map 操作都可以用 ofap 來實現。換句話說,map 只是 ap 在處理一個「來自一般世界」的函數時的語法糖。Applicative 的 ap 是一個更通用的機制,它統一了函數在容器內外兩種情況下的應用方式。

容器世界中的函數應用

Applicative 的 ofap 組合起來,創造了一種極其流暢且直觀的鏈式呼叫風格。回顧上面的例子:

const finalConfig = Maybe.of(createConfig)
 .ap(theme)
 .ap(port);

這種寫法看起來和一般的函數呼叫非常相似。

在一般值的世界裡,一個柯里化函數的呼叫看起來是這樣:

createConfig('dark')(8080)

// 或是

add(2)(3)

在容器包裹值的世界裡,Applicative 的鏈式呼叫則是:

Maybe.of(createConfig).ap(Maybe.of('dark')).ap(Maybe.of(8080))

// 或是

Maybe.of(add).ap(Maybe.of(2)).ap(Maybe.of(3)); // Maybe(5)

每一個 .ap(...) 都像是在逐步應用一個參數。Maybe.of(createConfig) 提供了包裹後的函數,第一個 .ap(theme) 消耗了 theme 這個參數並回傳一個包裹著「等待下一個參數的函數」的新 Maybe,接著第二個 .ap(port) 消耗了 port,最終得到結果。這過程中,of 負責將每個值都送進容器,ap 負責在容器內的世界進行函數套用。

這種語法上的對應,讓我們可以像思考一般函數呼叫一樣來組織程式碼邏輯,而 Applicative 容器則在幕後默默處理了那些複雜的 context(例如 Maybe 的空值檢查)。

抽象化模式:lift 函數

雖然 .ap() 的鏈式調用已經蠻好使用,但當參數變多時,程式碼還是會顯得有些冗長,會需要一直連續的 .ap() 傳入參數,此時可進一步思考如何讓這流程抽象化為通用函數。

我們知道 map 等同於 of 之後 ap,因此可利用 map 的此特性撰寫可接受任意參數數量的泛用函式,例如 liftA2liftA3 等。

liftA2 的名字聽起來可能有點奇怪,但它的意思就是:「將一個接受 2 個參數的一般函數,提升 (lift) 到 Applicative 的世界裡,使其可以操作 2 個被包裹的值」,liftA3 則是將接收 3 個參數的一般函數提升到 Applicative 的世界裡。

liftA2 實作如下:

const liftA2 = curry((fn, applicative1, applicative2) =>
  applicative1.map(fn).ap(applicative2)
);

liftA2 會先將雙參數函數 fn map 到第一個容器 applicative1 上,得到一個包裹起來的單參數函數,然後再用 ap 將它應用到第二個容器 applicative2 上。

有了 liftA2,我們的 createConfig 例子就可以寫得更簡潔:

const finalConfig = liftA2(createConfig, theme, port);
// 結果: Just({theme: "dark", port: 8080})

再看一個用 Either 驗證使用者資訊的範例,來理解使用 apliftA2 的差異:

// checkEmail :: User -> Either String Email
// checkName :: User -> Either String String
const user = { name: 'John Doe', email: 'blurp_blurp' };

const createUser = curry((email, name) => { /* creating... */ });

// 明確使用 ap
Either.of(createUser).ap(checkEmail(user)).ap(checkName(user));
// => Left('invalid email')

// 使用 liftA2(pointfree)
liftA2(createUser, checkEmail(user), checkName(user));
// => Left('invalid email')

liftA2 將我們的關注點從「如何操作 (map 然後 ap)」轉移到了「做什麼 (將 createConfig 應用於 themeport)」。程式碼變得更具宣告性,也更通用,因為它完全沒有提到 Maybe 或 Either 這個具體的類型。

簡言之,liftA2liftA(N) 是 applicative 操作中非常實用的工具,這種寫法的好處是:

  • 可在不明確使用容器名稱的情況下,進行函式應用與參數綁定
  • 可提升泛用性、可重用性與可讀性

另一個世界的語法:中綴運算子 <$><*>

稍微補充下,在 Haskell、Scala、PureScript、Swift 等語言中,可以自訂中綴(infix) 運算子,因此會看到這樣的語法:

add <$> Right 2 <*> Right 3

對應到 JavaScript,等價寫法為:

map(add, Right(2)).ap(Right(3));

<$><*> 這兩個運算子其實是 mapap 的中綴版本,可以讓程式碼更接近數學公式式的表達方式。

  • <$> (讀作 "mapped over" 或 "f-map") 等同於 map
  • <*> (讀作 "applied to" 或 "ap") 等同於 ap

以下簡單整理 Haskell / PureScript 與 JavaScript 的對照表,以進一步理解語法的操作流程。

語言 範例程式碼 對應概念
Haskell / PureScript add <$> Right 2 <*> Right 3 <$> = map / fmap<*> = ap
JavaScript map(add, Right(2)).ap(Right(3)) map(add, Right(2))Right(add(2))再用 .ap(Right(3))Right(5)
  • <$>fmap)的意思是把函數 add 放進去並作用於容器的值,結果變成 Right(add(2))
  • <*>ap)的意思是把前一步得到的 Right(add(2)) 應用到另一個容器值 Right(3),結果得到 Right(5)

小結

今天先簡單介紹了 Applicative 是什麼,明天會再繼續介紹 Applicative 要遵守的定律,以及一些應用範例,來更了解何時適合使用 Applicative。

Reference


上一篇
[Day 22] Monad 入門 (2):核心概念與定律
下一篇
[Day 24] Applicative Functor (2):定律與應用範例
系列文
30 天的 Functional Programming 之旅24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言